﻿/*
 * Copyright (C) 2014 Cypress Data Defense, LLC    
 * oss@cypressdefense.com
 * 
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
*/

using System;
using System.Configuration;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using System.IO;

using CypressDefense.Security.Shim.Configuration;
using CypressDefense.Security.Shim.Configuration.Base;
using CypressDefense.Security.Shim.Configuration.Caching;
using CypressDefense.Security.Shim.Configuration.Csp;
using CypressDefense.Security.Shim.Configuration.XFrameOptions;
using CypressDefense.Security.Shim.Configuration.XXssProtection;
using CypressDefense.Security.Shim.Configuration.XContentTypeOptions;
using CypressDefense.Security.Shim.Configuration.Hsts;

namespace CypressDefense.Security.Shim
{
    public class Module : IHttpModule
    {
        #region Private Variables
        private const string CONFIG_KEY = "SecureHeadersConfiguration";
        private const string PATH_POCO_LIST = "SecureHeadersPathPocoList";
        private const string EXPIRES_VALUE_KEY = "EXPIRESVALUE";
        private const string PRAGMA_VALUE_KEY = "PRAGMAVALUE";
        private const string CSP_VALUE_KEY = "CONTENTSECURITYPOLICYVALUE";
        private const string HSTS_VALUE_KEY = "STRICTTRANSPORTSECURITYVALUE";
        private const string XCONTENTTYPEOPTIONS_VALUE_KEY = "XCONTENTTYPEOPTIONSVALUE";
        private const string XFRAMEOPTIONS_VALUE_KEY = "XFRAMEOPTIONSVALUE";
        private const string X_XSSPROTECTION_VALUE_KEY = "XXSSPROTECTIONVALUE";

        private ShimConfiguration config;
        private HttpContext context;
        
        /// <summary>
        /// As pages are visited in the web site, their configured values are added to this object, which is cached in the Application memory pool for
        /// fast retrieval on future visits to the page
        /// </summary>
        private Dictionary<string, RelativePathConfig> pathPOCOList
        {
            get
            {
                if (context.Application[PATH_POCO_LIST] == null)
                    context.Application[PATH_POCO_LIST] = new Dictionary<string, RelativePathConfig>();

                return (Dictionary<string, RelativePathConfig>)context.Application[PATH_POCO_LIST];
            }
        }
        #endregion

        #region IHttpModule Handlers
        public void Init(HttpApplication app)
        {
            //Set up event handlers
            app.PreSendRequestHeaders += module_PreSendRequestHeaders;

            //Load configuration from web.config
            getConfiguration(app.Context);
        }

        void module_PreSendRequestHeaders(object sender, EventArgs e)
        {
            //Get the application and return if there is none
            HttpApplication app = sender as HttpApplication;
            if (app == null)
                return;

            //Get the context and return if there is none
            context = app.Context;
            if (context == null)
                return;

            //Get the parse configuration from the application cache, or from the config file if missing
            config = getConfiguration(context);

            //If config is disabled, do nothing
            if (!config.Enabled)
                return;

            //Get the relative path to see if we already have a cache object.
            string relativePath = getRelativeRequestPath();

            //Retrieve or create the PathPOCO object
            RelativePathConfig pathConfig = null;
            if (pathPOCOList.ContainsKey(relativePath))
                pathConfig = pathPOCOList[relativePath];
            else
            {
                pathConfig = new RelativePathConfig();
                pathConfig.RelativePath = relativePath;

                //Check exclusion list and return if excluded
                if (config.Caching.Enabled)
                {
                    bool enabled = !isPageExcluded(relativePath, config.Caching.Exclude.Values);

                    //Check exclusion list and return if excluded
                    if (config.Caching.CacheControl.Enabled)
                        pathConfig.CacheControlEnabled = enabled;

                    //Check exclusion list and return if excluded
                    if (config.Caching.Expires.Enabled)
                        pathConfig.CachingExpiresEnabled = enabled;

                    //Check exclusion list and return if excluded
                    if (config.Caching.Pragma.Enabled)
                        pathConfig.CachingPragmaEnabled = enabled;
                }

                //Check exclusion list and return if excluded
                if (!config.ContentSecurityPolicy.Enabled || isPageExcluded(relativePath, config.ContentSecurityPolicy.Exclude.Values))
                    pathConfig.ContentSecurityPolicyEnabled = false;

                //Check exclusion list and return if excluded
                if (isPageExcluded(relativePath, config.XContentTypeOptions.Exclude.Values))
                    pathConfig.XContentTypeOptionsEnabled = false;

                //Check exclusion list and return if excluded
                if (isPageExcluded(relativePath, config.StrictTransportSecurity.Exclude.Values))
                    pathConfig.StrictTransportSecurityEnabled = false;

                //Check exclusion list and return if excluded
                if (isPageExcluded(relativePath, config.XFrameOptions.Exclude.Values))
                    pathConfig.XFrameOptionsEnabled = false;

                //Check exclusion list and return if excluded
                if (isPageExcluded(relativePath, config.XXssProtection.Exclude.Values))
                    pathConfig.XXssProtectionEnabled = false;

                pathPOCOList.Add(relativePath, pathConfig);
            }

            //If enabled, set the caching headers
            if (pathConfig.CacheControlEnabled || pathConfig.CachingExpiresEnabled || pathConfig.CachingPragmaEnabled)
                setCachingHeaders(pathConfig);

            //If enabled, set the CSP header
            if (pathConfig.ContentSecurityPolicyEnabled)
                setCspHeader(pathConfig);

            //If enabled, set HSTS
            if (pathConfig.StrictTransportSecurityEnabled)
                setHstsHeader(pathConfig);

            //If enabled, set content type options
            if (pathConfig.XContentTypeOptionsEnabled)
                setXContentTypeOptionsHeader(pathConfig);

            //If enabled, set x-frame-options
            if (pathConfig.XFrameOptionsEnabled)
                setXFrameOptionsHeader(pathConfig);

            //If enabled, set x-xss-content type
            if (pathConfig.XXssProtectionEnabled)
                setXXssProtectionHeader(pathConfig);
        }

        public void Dispose()
        {
            //todo: remove app variables
            context.Application.Remove(PATH_POCO_LIST);
        }
        #endregion

        #region Helper Methods
        private ShimConfiguration getConfiguration(HttpContext context)
        {
            ShimConfiguration config = null;

            //Add to the application context if missing
            if (context.Application[CONFIG_KEY] == null)
            {
                config = ShimConfiguration.GetConfiguration();
                context.Application.Add(CONFIG_KEY, config);
            }
            else
                //Read the configuration from the application context
                config = (ShimConfiguration)context.Application[CONFIG_KEY];

            return config;
        }

        #region Exclude Methods
        /// <summary>
        /// Method uses the context and the locations list to determine if this request should be skipped for adding headers
        /// </summary>
        /// <param name="relativePath">The relative path.</param>
        /// <param name="locations">The locations.</param>
        /// <returns></returns>
        //ABC
        //private bool isPageExcluded(HttpContext context, Configuration.Base.LocationElementCollection locations)
        private bool isPageExcluded(string relativePath, Configuration.Base.LocationElementCollection locations)
        {
            bool exclude = false;

            if (locations.Count == 0)
                return exclude;

            //Compare the virtual path from the request to the virtual path in the exclusion list. 
            //If the full relative path (/dir/file) stars with the exclusion specificd, we will ignore it
            //E.g. /admin/users/edit would be excluded by the following configured exclusions "/admin", "/admin/users", or "/admin/users/edit"
            foreach (LocationElement l in locations)
            {
                exclude = relativePath.StartsWith(l.RelativePath, StringComparison.OrdinalIgnoreCase);

                //Break out of the loop if we hit a match
                if (exclude)
                    break;
            }

            return exclude;
        }

        /// <summary>
        /// Gets the relative request path. Call differentiates between web forms and MVC requests.
        /// We need to use the route data for MVC,
        /// as there is not a physical file on disk to match up with
        /// </summary>
        /// <returns></returns>
        private string getRelativeRequestPath()
        {
            string relativePath = context.Request.Path;

            //MVC sytle path
            if (context.Request.RequestContext != null && context.Request.RequestContext.RouteData != null && context.Request.RequestContext.RouteData.Values != null
                && context.Request.RequestContext.RouteData.Values.Values != null && context.Request.RequestContext.RouteData.Values.Values.Count > 0)
                relativePath = string.Format("/{0}", string.Join("/", context.Request.RequestContext.RouteData.Values.Values));

            //Defautl to the virtural path from the request
            return relativePath;
        }
        #endregion

        #region Caching Methods
        //ABC - use path
        //private void setCachingHeaders(HttpContext context, ShimConfiguration config)
        private void setCachingHeaders(RelativePathConfig pathConfig)
        {
            //Set cache-control
            if (pathConfig.CacheControlEnabled)
            {
                //Build up the cache-control values from the config
                foreach (AddElementCacheControl c in config.Caching.CacheControl.Values)
                {
                    CacheControlType type = c.Value;
                    if (type == CacheControlType.NoCache)
                        context.Response.Cache.SetCacheability(HttpCacheability.NoCache);

                    if (type == CacheControlType.NoStore)
                        context.Response.Cache.SetNoStore();

                    if (type == CacheControlType.MustRevalidate)
                        context.Response.Cache.SetRevalidation(HttpCacheRevalidation.AllCaches);

                    if (type == CacheControlType.ProxyRevalidate)
                        context.Response.Cache.SetRevalidation(HttpCacheRevalidation.ProxyCaches);

                    if (type == CacheControlType.Private)
                        context.Response.Cache.SetCacheability(HttpCacheability.Private);

                    if (type == CacheControlType.Public)
                        context.Response.Cache.SetCacheability(HttpCacheability.Public);
                }
            }

            //Set expires
            if (pathConfig.CachingExpiresEnabled)
                context.Response.AddHeader(Resources.HeaderValues.ExpiresHeader, config.Caching.Expires.Value.ToString());

            //Set pragma
            if (pathConfig.CachingPragmaEnabled)
            {
                if (string.IsNullOrEmpty(pathConfig.CachingPragmaHeaderValue))
                    pathConfig.CachingPragmaHeaderValue = getPragmaHeaderValue(config.Caching.Pragma, context);

                context.Response.AddHeader(Resources.HeaderValues.PragmaHeader, pathConfig.CachingPragmaHeaderValue);
            }
        }

        private string getPragmaHeaderValue(CacheControlElement e, HttpContext context)
        {
            if (context.Application[PRAGMA_VALUE_KEY] == null)
            {
                string value = string.Empty;
                foreach (AddElementCacheControl c in e.Values)
                    value = string.Format("{0}{1}, ", value, getCacheItemValue(c.Value));

                //Trim trailing commma and spaces
                value = value.TrimEnd(',', ' ');

                //Cache the value in the application object for future requests
                context.Application[PRAGMA_VALUE_KEY] = value;

                return value;
            }
            else
                return context.Application[PRAGMA_VALUE_KEY].ToString();
        }

        /// <summary>
        /// Method returns the cache control value from the resources file
        /// </summary>
        /// <param name="type"></param>
        /// <returns></returns>
        private string getCacheItemValue(CacheControlType type)
        {
            switch (type)
            {
                case CacheControlType.MustRevalidate:
                    return Resources.HeaderValues.MustRevalidate;
                case CacheControlType.NoCache:
                    return Resources.HeaderValues.NoCache;
                case CacheControlType.NoStore:
                    return Resources.HeaderValues.NoStore;
                case CacheControlType.Private:
                    return Resources.HeaderValues.Private;
                case CacheControlType.ProxyRevalidate:
                    return Resources.HeaderValues.ProxyRevalidate;
                case CacheControlType.Public:
                    return Resources.HeaderValues.Public;
                default:
                    return string.Empty;
            }
        }
        #endregion

        #region CSP Methods
        private void setCspHeader(RelativePathConfig pathConfig)
        {
            if (string.IsNullOrEmpty(pathConfig.CSPHeaderValue))
                pathConfig.CSPHeaderValue = getCspHeaderValue(config.ContentSecurityPolicy, context);

            //Add the header to the response
            context.Response.AddHeader(
                config.ContentSecurityPolicy.ReportOnly ? Resources.HeaderValues.CspReportOnlyHeader : Resources.HeaderValues.CspHeader
                , pathConfig.CSPHeaderValue);
        }
        private string getCspHeaderValue(CspElement e, HttpContext context)
        {
            if (context.Application[CSP_VALUE_KEY] == null)
            {
                //String builder to hold header value
                string value = string.Empty;

                //Set default-src
                if (e.DefaultSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspDefaultSource, e.DefaultSource));

                //Set script-src
                if (e.ScriptSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspScriptSource, e.ScriptSource));

                //Set object-src
                if (e.ObjectSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspObjectSource, e.ObjectSource));

                //Set style-src
                if (e.StyleSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspStyleSource, e.StyleSource));

                //Set img-src
                if (e.ImageSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspImageSource, e.ImageSource));

                //Set media-src
                if (e.MediaSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspMediaSource, e.MediaSource));

                //Set frame-src
                if (e.FrameSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspFrameSource, e.FrameSource));

                //Set font-src
                if (e.FontSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspFontSource, e.FontSource));

                //Set connect-src
                if (e.ConnectSource.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspConnectSource, e.ConnectSource));

                //Set report-uri
                if (e.ReportUri.Enabled)
                    value = string.Format("{0}{1}", value, getCSPDirective(Resources.HeaderValues.CspReportUri, e.ReportUri));

                //Cut the extra white space
                value = value.Replace(" ;", ";");

                //Trim spaces
                value = value.Trim();

                //Cache the value in the application object for future requests
                context.Application[CSP_VALUE_KEY] = value;

                return value;
            }
            else
                return context.Application[CSP_VALUE_KEY].ToString();
        }

        /// <summary>
        /// Method adds the list of sources, unsafe-inline, and unsafe-eval to the diven CSP directive based on the element configuration
        /// </summary>
        /// <param name="directive"></param>
        /// <param name="sourceElement"></param>
        /// <param name="sbCspValue"></param>
        private string getCSPDirective(string directive, CspSourceBaseElement sourceElement)
        {
            string cspValue = string.Format("{0} ", directive);
            foreach (AddElementCspSource s in sourceElement.Values)
                cspValue = string.Format("{0}{1} ", cspValue, getCSPItemValue(s.Value));

            //For script elements, add unsafe-eval and unsafe-inline if needed
            if (sourceElement is ScriptSourceElement)
            {
                if (((ScriptSourceElement)sourceElement).UnsafeInline)
                    cspValue = string.Format("{0}{1} ", cspValue, Resources.HeaderValues.CspUnsafeInline);

                if (((ScriptSourceElement)sourceElement).UnsafeEval)
                    cspValue = string.Format("{0}{1} ", cspValue, Resources.HeaderValues.CspUnsafeEval);
            }
            //For style-src, add unsafe-inline if needed
            else if (sourceElement is StyleSourceElement)
            {
                if (((StyleSourceElement)sourceElement).UnsafeInline)
                    cspValue = string.Format("{0}{1} ", cspValue, Resources.HeaderValues.CspUnsafeInline);
            }

            //Terminate with semicolon
            cspValue = string.Format("{0}; ", cspValue);

            return cspValue;
        }

        /// <summary>
        /// Method wraps the self and none keywords in tick marks, otherwise returns the uri
        /// </summary>
        /// <param name="item"></param>
        /// <returns></returns>
        private string getCSPItemValue(string item)
        {
            if (string.Compare(item, Resources.HeaderValues.CspNone, true) == 0)
                return string.Format("'{0}'", Resources.HeaderValues.CspNone);
            else if (string.Compare(item, Resources.HeaderValues.CspSelf, true) == 0)
                return string.Format("'{0}'", Resources.HeaderValues.CspSelf);
            else
                return item;
        }
        #endregion

        #region HSTS Methods
        private void setHstsHeader(RelativePathConfig pathConfig)
        {
            if (string.IsNullOrEmpty(pathConfig.HstsHeaderValue))
                pathConfig.HstsHeaderValue = getHstsHeaderValue(config.StrictTransportSecurity, context);

            //Add the header
            context.Response.AddHeader(Resources.HeaderValues.HstsHeader, pathConfig.HstsHeaderValue);
        }

        private string getHstsHeaderValue(StrictTransportSecurityElement e, HttpContext context)
        {
            if (context.Application[HSTS_VALUE_KEY] == null)
            {
                //Add the max-age parameter
                string value = string.Format("{0}={1}", Resources.HeaderValues.HstsMaxAge, e.MaxAge);

                //If specified, add includeSubDomains option
                if (e.IncludeSubDomains)
                    value = string.Format("{0}; {1}", value, Resources.HeaderValues.HstsIncludeSubDomains);

                //Cache the value in the application object for future requests
                context.Application[HSTS_VALUE_KEY] = value;

                return value;
            }
            else
            {
                return context.Application[HSTS_VALUE_KEY].ToString();
            }
        }
        #endregion

        #region X-CONTENT-TYPE-OPTIONS Methods
        private void setXContentTypeOptionsHeader(RelativePathConfig pathConfig)
        {
            if (string.IsNullOrEmpty(pathConfig.ContentTypeOptionsHeaderValue))
                pathConfig.ContentTypeOptionsHeaderValue = getContentTypeOptionsHeaderValue(config.XContentTypeOptions, context);

            //Add the header
            context.Response.AddHeader(Resources.HeaderValues.XContentTypeOptionsHeader, pathConfig.ContentTypeOptionsHeaderValue);
        }

        private string getContentTypeOptionsHeaderValue(XContentTypeOptionsElement e, HttpContext context)
        {
            if (context.Application[XCONTENTTYPEOPTIONS_VALUE_KEY] == null)
            {
                //Get the value from the resources file
                string value = getContentTypeOptionValue(e.Value);

                //Cache the value in the application object for future requests
                context.Application[XCONTENTTYPEOPTIONS_VALUE_KEY] = value;

                return value;
            }
            else
                return context.Application[XCONTENTTYPEOPTIONS_VALUE_KEY].ToString();
        }

        /// <summary>
        /// Returns the X-Content-Type-Options string based on the give enumeration
        /// </summary>
        /// <param name="type"></param>
        /// <returns></returns>
        private string getContentTypeOptionValue(XContentTypeOptions type)
        {
            if (type == XContentTypeOptions.NoSniff)
                return Resources.HeaderValues.XContentTypeOptionsNoSniff;
            else
                return string.Empty;
        }
        #endregion

        #region X-FRAME-OPTIONS Header
        private void setXFrameOptionsHeader(RelativePathConfig pathConfig)
        {
            if (string.IsNullOrEmpty(pathConfig.XFrameOptionsHeaderValue))
                pathConfig.XFrameOptionsHeaderValue = getXFrameOptionsHeaderValue(config.XFrameOptions, context);

            //Add the header
            context.Response.AddHeader(Resources.HeaderValues.XFrameOptionsHeader, pathConfig.XFrameOptionsHeaderValue);
        }

        /// <summary>
        /// Methods builds the X-FRAME-OPTIONS header value from the configuration and caches the value
        /// in the application context for future requests to minimize memory usage going forward.
        /// </summary>
        /// <param name="e"></param>
        /// <param name="context"></param>
        /// <returns></returns>
        private string getXFrameOptionsHeaderValue(XFrameOptionsElement e, HttpContext context)
        {
            if (context.Application[XFRAMEOPTIONS_VALUE_KEY] == null)
            {
                //Get the value from the resource file
                string value = getXFrameOptionsValue(e.Value);

                //If allow from, append the URI
                if (e.Value == XFrameOptionType.AllowFrom)
                    value = string.Format("{0} {1}", value, e.AllowFromUri);

                //Cache the value in the application object for future requests
                context.Application[XFRAMEOPTIONS_VALUE_KEY] = value;

                return value;
            }
            else
                return context.Application[XFRAMEOPTIONS_VALUE_KEY].ToString();
        }

        /// <summary>
        /// Returns the header value for the given XFrameOptions type
        /// </summary>
        /// <param name="type"></param>
        /// <returns></returns>
        private string getXFrameOptionsValue(XFrameOptionType type)
        {
            switch (type)
            {
                case XFrameOptionType.AllowFrom:
                    return Resources.HeaderValues.XFrameOptionsAllowFrom;
                case XFrameOptionType.Deny:
                    return Resources.HeaderValues.XFrameOptionsDeny;
                case XFrameOptionType.SameOrigin:
                    return Resources.HeaderValues.XFrameOptionsSameOrigin;
                default:
                    return string.Empty;
            }
        }


        #endregion

        #region X-XSS-PROTECTION Methods
        private void setXXssProtectionHeader(RelativePathConfig pathConfig)
        {
            if (string.IsNullOrEmpty(pathConfig.XXssProtectionHeaderValue))
                pathConfig.XXssProtectionHeaderValue = getXXssProtectionHeaderValue(config.XXssProtection, context);

            context.Response.AddHeader(Resources.HeaderValues.XXssProtectionHeader, pathConfig.XXssProtectionHeaderValue);
        }

        /// <summary>
        /// Methods builds the X-XSS-PROTECTION header value from the configuration and caches the value
        /// in the application context for future requests to minimize memory usage going forward.
        /// </summary>
        /// <param name="e"></param>
        /// <param name="context"></param>
        /// <returns></returns>
        private string getXXssProtectionHeaderValue(XXssProtectionElement e, HttpContext context)
        {
            if (context.Application[X_XSSPROTECTION_VALUE_KEY] == null)
            {
                //Get the value from the config file
                string value = e.Value.ToString();

                //If block is enabled, add block attribute
                if (e.Block)
                    value = string.Format("{0}; {1}", value, Resources.HeaderValues.XXssProtectionBlockMode);

                //Cache the value in the application object for future requests
                context.Application[X_XSSPROTECTION_VALUE_KEY] = value;

                return value;
            }
            else
            {
                return context.Application[X_XSSPROTECTION_VALUE_KEY].ToString();
            }
        }
        #endregion
        #endregion
    }
}
